spb/datacenterindex
Public
HTML 53.9%
TypeScript 44.5%
JavaScript 0.6%
SQL 0.5%
1import type { FacilityStatus, MetroDetail } from "@dci/core";2import { ExternalLink } from "lucide-react";3import type { Metadata } from "next";4import Link from "next/link";5import { notFound } from "next/navigation";6import { Breadcrumbs } from "@/components/entity/breadcrumbs";7import { EntityMap } from "@/components/entity/entity-map";8import { JsonLd } from "@/components/entity/json-ld";9import { fPageHref, PagedFacilities, parseFPage } from "@/components/entity/paged-facilities";10import { SectionNav } from "@/components/entity/section-nav";11import { SourcesFooter } from "@/components/entity/sources-footer";12import { EventList } from "@/components/event-list";13import { ClaimsTable } from "@/components/evidence";14import { FacilityTable } from "@/components/facility-table";15import { MarketConcentrationPanel } from "@/components/intelligence/market/concentration";16import { MarketMomentumPanel } from "@/components/intelligence/market/momentum";17import { OpeningTimeline } from "@/components/intelligence/market/opening-timeline";18import { ApiNote, CoverageBar, CoverageStrip, GridConstraintList, HonestEmpty, PillLink, PipelineBars, PublishedOnlyNote, ShareList } from "@/components/intelligence/shared";19import { WatchButton } from "@/components/intelligence/watch-button";20import { ProjectList } from "@/components/project-list";21import { Badge, EstimateMark } from "@/components/ui/badge";22import { Page, Section } from "@/components/ui/card";23import { StackedBar } from "@/components/ui/charts";24import { CountryFlag } from "@/components/ui/country-flag";25import { Metric, MetricCell, MetricRow } from "@/components/ui/metric";26import { ErrorState, OfflineNotice } from "@/components/ui/states";27import { RowLink, TBody, THead, Table, Td, Th, Tr } from "@/components/ui/table";28import type { DetailFacilitiesMeta } from "@/lib/dto";29import { fmtDate, fmtInt, fmtMw, fmtPct, pluralize, splitMw } from "@/lib/format";30import { routes } from "@/lib/routes";31import { compactJsonLd, NOT_FOUND_METADATA, pageMetadata } from "@/lib/seo";32import type { SearchParams } from "@/lib/sort";33import { countryHref, countrySlugMap, pipelineMw } from "../country-links";34import { getMetro } from "./data";35import { GrowthChart } from "./growth-chart";3637export const revalidate = 300;3839type Props = { params: Promise<{ slug: string }>; searchParams: Promise<SearchParams> };4041function describe(m: MetroDetail): string {42 const parts = [`${fmtInt(m.facilityCount)} data center ${m.facilityCount === 1 ? "facility" : "facilities"} in ${m.name}${m.countryName ? `, ${m.countryName}` : ""}`];43 if (m.knownMw !== null) parts.push(`${fmtMw(m.knownMw)} known operational capacity (${fmtPct(m.mwCoverage)} of facilities publish a figure)`);44 const pipe = pipelineMw(m);45 if (pipe !== null) parts.push(`${fmtMw(pipe)} in the pipeline`);46 parts.push(`${fmtInt(m.operatorCount)} operators`);47 if (m.cloudRegionCount) parts.push(`${fmtInt(m.cloudRegionCount)} cloud regions`);48 return `${parts.join(", ")}. Operators, concentration, momentum, pipeline, connectivity, power context and coverage for the ${m.name} market — every figure sourced.`;49}5051export async function generateMetadata({ params }: Props): Promise<Metadata> {52 const { slug } = await params;53 const r = await getMetro(slug);54 if (!r.ok) return r.status === 404 ? NOT_FOUND_METADATA : { title: "Market" };55 return pageMetadata({ title: `Data Centers in ${r.data.name}`, description: describe(r.data), path: routes.market(r.data.slug) });56}5758const CONSTRAINT_LABEL: Record<MetroDetail["constraints"][number]["kind"], string> = { power: "Power", land: "Land", water: "Water", moratorium: "Moratorium", grid: "Grid" };5960function Mw({ mw, estimate }: { mw: number | null | undefined; estimate?: boolean }) {61 const s = splitMw(mw);62 if (!s) return <span className="text-ink-4">—</span>;63 return (64 <>65 {estimate && <EstimateMark className="mr-0.5 text-[0.6em]" />}66 {s.value}67 </>68 );69}70const unitOf = (mw: number | null | undefined) => splitMw(mw)?.unit ?? undefined;7172export default async function MarketPage({ params, searchParams }: Props) {73 const [{ slug }, sp] = await Promise.all([params, searchParams]);74 const fPage = parseFPage(sp.fPage);75 const [r, { map }] = await Promise.all([getMetro(slug, fPage), countrySlugMap()]);76 if (!r.ok && r.status === 404) notFound();77 if (!r.ok) {78 return (79 <Page className="py-6">80 <OfflineNotice detail={r.error} className="mb-4" />81 <ErrorState />82 </Page>83 );84 }85 const m = r.data;86 const meta = (r.meta ?? {}) as DetailFacilitiesMeta;87 const facTotal = meta.facilitiesTotal ?? m.facilityCount;88 const facPage = meta.facilitiesPage ?? fPage;89 const facPer = meta.facilitiesPerPage ?? 50;90 const pipe = pipelineMw(m);91 const cHref = countryHref(map, m.countryIso2);92 const base = routes.market(m.slug);93 const aiFacilities = m.aiFacilities ?? [];94 const gridConstraints = m.gridConstraints ?? [];95 const claims = m.claims ?? [];96 const openingTimeline = m.openingTimeline ?? [];97 const aiCount = m.aiCount ?? aiFacilities.length;9899 const statusData: Partial<Record<FacilityStatus, number>> = {};100 if (m.operationalCount) statusData.operational = m.operationalCount;101 if (m.constructionCount) statusData.under_construction = m.constructionCount;102 if (m.plannedCount) statusData.announced = m.plannedCount;103 const other = m.facilityCount - m.operationalCount - m.constructionCount - m.plannedCount;104 if (other > 0) statusData.unknown = other;105106 const hasCloud = m.cloudProviders.length > 0 || m.cloudRegions.length > 0;107 const powerItems = gridConstraints.length + m.constraints.length;108 const sections = [109 { id: "map", label: "Map" },110 { id: "status", label: "Status" },111 { id: "operators", label: "Operators", count: m.operators.length },112 { id: "pipeline", label: "Pipeline" },113 { id: "openings", label: "Openings" },114 { id: "concentration", label: "Concentration" },115 { id: "momentum", label: "Momentum" },116 { id: "connectivity", label: "Connectivity" },117 { id: "power", label: "Power" },118 { id: "coverage", label: "Coverage" },119 { id: "ai", label: "AI", count: aiFacilities.length },120 { id: "projects", label: "Projects", count: m.projects.length },121 { id: "changes", label: "Changes", count: m.recentEvents.length },122 { id: "facilities", label: "Facilities", count: facTotal },123 { id: "claims", label: "Claims", count: claims.length },124 ];125126 const jsonLd = compactJsonLd({127 "@context": "https://schema.org",128 "@type": "Place",129 name: `${m.name} data center market`,130 url: `${routes.market(m.slug)}`,131 address: { "@type": "PostalAddress", addressLocality: m.name, addressRegion: m.regionName ?? undefined, addressCountry: m.countryIso2 },132 geo: { "@type": "GeoCoordinates", latitude: m.lat, longitude: m.lng },133 description: describe(m),134 });135136 const topOps = [...m.operators].sort((a, b) => b.facilityCount - a.facilityCount).slice(0, 15);137 const opsTotalFac = m.facilityCount || 1;138139 return (140 <Page>141 <Breadcrumbs items={[{ name: "Data centers", href: routes.datacenters() }, { name: "Markets", href: routes.markets() }, { name: m.name, href: base }]} className="pt-3" />142 <JsonLd data={jsonLd} />143144 <header className="py-5 sm:py-7">145 <div className="flex flex-wrap items-start justify-between gap-3">146 <div className="min-w-0">147 <div className="label-caps mb-1.5">Market</div>148 <h1 className="text-[22px] font-semibold leading-tight tracking-[-0.02em] text-ink sm:text-[28px]">Data centers in {m.name}</h1>149 <div className="mt-2 flex flex-wrap items-center gap-x-2 gap-y-1 text-[13px] text-ink-2">150 <CountryFlag iso2={m.countryIso2} size={13} />151 {cHref ? (152 <Link href={cHref} className="text-ink hover:text-accent hover:underline">153 {m.countryName ?? m.countryIso2}154 </Link>155 ) : (156 <span>{m.countryName ?? m.countryIso2}</span>157 )}158 {m.regionName && m.regionName !== m.name && (159 <>160 <span aria-hidden className="text-ink-4">161 ·162 </span>163 <span className="text-ink-3">{m.regionName}</span>164 </>165 )}166 <span aria-hidden className="text-ink-4">167 ·168 </span>169 <span className="figure text-[12px] text-ink-3">170 {m.lat.toFixed(2)}, {m.lng.toFixed(2)}171 </span>172 </div>173 </div>174 <div className="flex flex-wrap items-center gap-2">175 <WatchButton entityType="metro" slug={m.slug} entityId={m.id} name={m.name} />176 <PillLink href={routes.explore(`entity=facilities&metro=${m.slug}`)}>Explore →</PillLink>177 </div>178 </div>179 {m.description && <p className="mt-2 max-w-2xl text-[13.5px] text-ink-2">{m.description}</p>}180 {m.aliases.length > 0 && (181 <div className="mt-3">182 <div className="label-caps mb-1.5">Sub-markets</div>183 <div className="flex flex-wrap gap-1">184 {m.aliases.slice(0, 8).map((a) => (185 <Badge key={a} tone="outline" size="xs">186 {a}187 </Badge>188 ))}189 {m.aliases.length > 8 && (190 <details className="inline">191 <summary className="inline-flex h-[18px] cursor-pointer list-none items-center rounded-xs border border-hair-2 px-1.5 text-[10.5px] font-medium text-ink-3 hover:text-ink [&::-webkit-details-marker]:hidden">+{m.aliases.length - 8} more</summary>192 <span className="mt-1 flex flex-wrap gap-1">193 {m.aliases.slice(8).map((a) => (194 <Badge key={a} tone="outline" size="xs">195 {a}196 </Badge>197 ))}198 </span>199 </details>200 )}201 </div>202 </div>203 )}204 </header>205206 <MetricRow cols={4}>207 <MetricCell>208 <Metric value={fmtInt(m.facilityCount)} label="Facilities" note={`${fmtInt(m.operationalCount)} operational`} size="sm" />209 </MetricCell>210 <MetricCell>211 <Metric value={fmtInt(m.operatorCount)} label="Operators" size="sm" />212 </MetricCell>213 <MetricCell>214 <Metric value={<Mw mw={m.knownMw} />} unit={unitOf(m.knownMw)} label="Known MW" note={<CoverageBar value={m.mwCoverage ?? null} label="MW coverage" width="w-full max-w-[140px]" />} size="sm" />215 </MetricCell>216 <MetricCell>217 <Metric value={<Mw mw={pipe} estimate />} unit={unitOf(pipe)} label="Pipeline MW" note={pipe === null ? `${fmtInt(m.constructionCount + m.plannedCount)} ${pluralize(m.constructionCount + m.plannedCount, "site")}, MW not published` : `${fmtInt(m.constructionCount)} under construction · ${fmtInt(m.plannedCount)} planned`} size="sm" />218 </MetricCell>219 <MetricCell>220 <Metric value={fmtInt(m.cloudRegionCount)} label="Cloud regions" size="sm" />221 </MetricCell>222 <MetricCell>223 <Metric value={fmtInt(m.ixpCount)} label="IXPs" size="sm" />224 </MetricCell>225 <MetricCell>226 <Metric value={fmtInt(aiCount)} label="AI / HPC sites" note="confirmed + likely evidence" size="sm" />227 </MetricCell>228 <MetricCell>229 <Metric value={fmtInt(m.projectCount)} label="Projects" note={m.projectPlannedMw ? `≈ ${fmtMw(m.projectPlannedMw)} planned` : undefined} size="sm" />230 </MetricCell>231 </MetricRow>232233 {m.rankings.length > 0 && (234 <div className="-mx-3 flex gap-1.5 overflow-x-auto px-3 py-3 no-scrollbar sm:mx-0 sm:flex-wrap sm:px-0" aria-label="Ranking positions">235 {m.rankings.map((rk) => (236 <Link key={rk.key} href={routes.ranking(rk.key)} className="inline-flex h-10 shrink-0 items-center gap-1.5 rounded-xs border border-hair bg-surface px-2 text-[12px] text-ink-2 hover:border-hair-2 hover:text-ink sm:h-7">237 <span className="figure font-medium text-ink">#{rk.rank}</span>238 <span className="figure text-ink-4">/ {rk.total}</span>239 <span>{rk.label}</span>240 </Link>241 ))}242 </div>243 )}244245 <SectionNav items={sections} className="mt-2" />246247 <div className="flex flex-col gap-8 pt-6">248 <Section id="map" title="Map" description="Facilities in and around the market; the view opens the interactive map filtered to the country." className="scroll-mt-28">249 <EntityMap center={{ lat: m.lat, lng: m.lng }} zoom={9} filters={{ country: m.countryIso2 }} openLabel={`Open ${m.name} in map`} className="h-[240px] sm:h-[360px]" />250 </Section>251252 <Section id="status" title="Status" description="Facilities by lifecycle status." className="scroll-mt-28">253 <StackedBar data={statusData} total={m.facilityCount} />254 </Section>255256 <Section id="operators" title="Top operators" description="By facilities operated in this market; share of the market's facility count." className="scroll-mt-28" aside={<span className="figure text-[12px] text-ink-3">{fmtInt(m.operatorCount)} total</span>}>257 {topOps.length ? <ShareList items={topOps.map((o) => ({ key: o.id, label: o.name, href: routes.operator(o.slug), count: o.facilityCount, share: o.facilityCount / opsTotalFac, mw: o.knownMw ?? null }))} unit="facilities" /> : <HonestEmpty title="No operator attributed yet" />}258 </Section>259260 <Section id="pipeline" title="Construction pipeline" description="Facilities by lifecycle bucket with the MW they publish, then project records by stage." className="scroll-mt-28" aside={<Link href={routes.projects(`metro=${m.slug}`)} className="text-[12px] text-ink-3 hover:text-ink">All projects →</Link>}>261 {m.pipeline ? <PipelineBars pipeline={m.pipeline} /> : <HonestEmpty title="Pipeline breakdown unavailable" />}262 <PublishedOnlyNote className="mt-3" />263 </Section>264265 <Section id="openings" title="Opening timeline" description="Facilities opened per published opening year." className="scroll-mt-28">266 {openingTimeline.length ? (267 <OpeningTimeline data={openingTimeline} />268 ) : (269 <HonestEmpty title="No published opening dates for this market" detail="operators and registries indexed here have not published when their facilities opened; nothing is estimated" />270 )}271 {m.growth.length >= 2 && (272 <div className="mt-5">273 <div className="label-caps mb-2">Cumulative growth</div>274 <GrowthChart data={m.growth} />275 </div>276 )}277 </Section>278279 <Section id="concentration" title="Market concentration" description="Herfindahl–Hirschman index and top-operator shares — on facility counts and, separately, on known MW." className="scroll-mt-28">280 {m.concentration ? <MarketConcentrationPanel c={m.concentration} facilityCount={m.facilityCount} /> : <HonestEmpty title="Concentration not computed" />}281 </Section>282283 <Section id="momentum" title="Market momentum" description="Separate, transparent components over the last 12 months. There is no composite score: each count stands on its own and links to its evidence." className="scroll-mt-28">284 {m.momentum ? <MarketMomentumPanel m={m.momentum} metroSlug={m.slug} countryIso2={m.countryIso2} /> : <HonestEmpty title="Momentum not computed" />}285 </Section>286287 <Section id="connectivity" title="Connectivity" description="Public cloud regions associated with this market and internet exchanges present in its facilities." className="scroll-mt-28" aside={<Link href={routes.connectivity()} className="text-[12px] text-ink-3 hover:text-ink">Connectivity index →</Link>}>288 {hasCloud ? (289 <div className="mb-5">290 <div className="label-caps mb-2">Cloud</div>291 {m.cloudProviders.length > 0 && (292 <ul className="mb-3 flex flex-wrap gap-1.5">293 {m.cloudProviders.map((p) => (294 <li key={p.id}>295 <Link href={routes.operator(p.slug)} className="inline-flex h-10 items-center gap-1.5 rounded-xs border border-hair bg-surface px-2 text-[12.5px] text-ink hover:border-hair-2 hover:text-accent sm:h-8">296 {p.name}297 <span className="figure text-[11px] text-ink-3">298 {p.regionCount} {p.regionCount === 1 ? "region" : "regions"}299 </span>300 </Link>301 </li>302 ))}303 </ul>304 )}305 {m.cloudRegions.length > 0 && (306 <Table dense mobileCollapse={{ total: m.cloudRegions.length, label: "cloud regions" }}>307 <THead>308 <tr>309 <Th>Region</Th>310 <Th className="hidden sm:table-cell">Provider</Th>311 <Th className="hidden md:table-cell">Code</Th>312 <Th numeric>AZs</Th>313 <Th>Status</Th>314 <Th className="hidden lg:table-cell">Launched</Th>315 </tr>316 </THead>317 <TBody>318 {m.cloudRegions.map((cr) => (319 <Tr key={cr.id}>320 <Td primary>321 <RowLink href={routes.cloudRegion(cr.slug)}>{cr.name}</RowLink>322 <div className="text-[11.5px] text-ink-3 sm:hidden">323 {cr.provider.name} · <span className="figure">{cr.code}</span>324 </div>325 </Td>326 <Td label="Provider" className="hidden sm:table-cell">327 <Link href={routes.operator(cr.provider.slug)} className="text-ink-2 hover:text-ink hover:underline">328 {cr.provider.name}329 </Link>330 </Td>331 <Td label="Code" className="figure hidden text-[12px] md:table-cell">332 {cr.code}333 </Td>334 <Td label="AZs" numeric>335 {cr.availabilityZones ?? <span className="text-ink-4">—</span>}336 </Td>337 <Td label="Status">338 <span className="inline-flex flex-wrap gap-1">339 <Badge tone={cr.status === "operational" ? "ok" : cr.status === "announced" ? "info" : "neutral"} size="xs">340 {cr.status}341 </Badge>342 {cr.isSovereign && (343 <Badge tone="outline" size="xs" title="Sovereign cloud region">344 Sovereign345 </Badge>346 )}347 </span>348 </Td>349 <Td label="Launched" className="figure hidden text-[12px] text-ink-3 lg:table-cell">350 {fmtDate(cr.launchedOn)}351 </Td>352 </Tr>353 ))}354 </TBody>355 </Table>356 )}357 <p className="mt-1.5 text-[11px] text-ink-4">Cloud regions are associated with the market, not hosted at a specific facility unless a source says so.</p>358 </div>359 ) : (360 <HonestEmpty title="No public cloud region associated with this market" className="mb-5" />361 )}362 <div className="label-caps mb-2">Internet exchanges</div>363 {m.ixps.length ? (364 <ul className="divide-y divide-hair">365 {m.ixps.map((x) => (366 <li key={x.id} className="flex items-baseline justify-between gap-3 py-2">367 <Link href={routes.ixp(x.slug)} className="text-[13.5px] font-medium text-ink hover:text-accent hover:underline">368 {x.name}369 </Link>370 <span className="figure shrink-0 text-[12px] text-ink-3">{x.networkCount === null ? "networks —" : `${fmtInt(x.networkCount)} networks`}</span>371 </li>372 ))}373 </ul>374 ) : (375 <HonestEmpty title="No IXP presence recorded in this market" detail="IXP presence comes from published IXP ↔ facility links; none is inferred" />376 )}377 </Section>378379 <Section id="power" title="Power context" description="Grid constraints, utility events and power agreements affecting this market." className="scroll-mt-28" aside={<Link href={routes.power()} className="text-[12px] text-ink-3 hover:text-ink">Power & grid →</Link>}>380 <GridConstraintList items={gridConstraints} showPlace={false} />381 {m.constraints.length > 0 && (382 <div className="mt-4">383 <div className="label-caps mb-1.5">Constraint mentions in indexed sources</div>384 <ul className="divide-y divide-hair">385 {m.constraints.map((c, i) => (386 <li key={i} className="py-2.5">387 <div className="flex flex-wrap items-center gap-2">388 <Badge tone={c.kind === "moratorium" || c.kind === "power" ? "warn" : "outline"} size="xs">389 {CONSTRAINT_LABEL[c.kind]}390 </Badge>391 {c.date && (392 <time className="figure text-[11.5px] text-ink-3" dateTime={c.date}>393 {fmtDate(c.date)}394 </time>395 )}396 </div>397 <p className="mt-1 text-[13px] text-ink-2">{c.summary}</p>398 <a href={c.url} target="_blank" rel="noopener noreferrer" className="mt-1 inline-flex min-h-6 items-center gap-1 text-[11.5px] text-ink-3 hover:text-accent">399 {c.sourceName} <ExternalLink className="size-3" />400 </a>401 </li>402 ))}403 </ul>404 </div>405 )}406 <ApiNote label="Reading" className="mt-3" note={`Observed events and curated public reports only — nothing is inferred${powerItems === 0 ? "; the absence of items means no report was indexed, not that the grid is unconstrained" : ""}. Utility and grid MW are supply-side figures, never IT load.`} />407 </Section>408409 <Section id="coverage" title="Coverage" description="How much of this market the index actually knows — shown so the figures above can be read honestly." className="scroll-mt-28" aside={<Link href={routes.coverage()} className="text-[12px] text-ink-3 hover:text-ink">Coverage report →</Link>}>410 {m.coverage ? <CoverageStrip row={m.coverage} /> : <HonestEmpty title="Coverage not computed" />}411 </Section>412413 {aiFacilities.length > 0 && (414 <Section id="ai" title="AI / HPC facilities" description="Facilities with confirmed or likely AI evidence in this market." className="scroll-mt-28" aside={<Link href={routes.ai()} className="text-[12px] text-ink-3 hover:text-ink">AI infrastructure →</Link>}>415 <FacilityTable items={aiFacilities} columns={["operator", "status", "mw", "type"]} />416 </Section>417 )}418419 {m.projects.length > 0 && (420 <Section id="projects" title="Projects" description="Announced, permitted and under-construction projects linked to this market." className="scroll-mt-28" aside={<Link href={routes.projects(`metro=${m.slug}`)} className="text-[12px] text-ink-3 hover:text-ink">All projects →</Link>}>421 <ProjectList items={m.projects} />422 </Section>423 )}424425 {m.recentEvents.length > 0 && (426 <Section id="changes" title="Recent changes" description="Detected changes on facilities and projects in this market." className="scroll-mt-28" aside={<Link href={routes.live(`metro=${m.slug}`)} className="text-[12px] text-ink-3 hover:text-ink">Live feed →</Link>}>427 <EventList events={m.recentEvents} dense />428 </Section>429 )}430431 <Section id="facilities" title="Facilities" description="Sorted by best known capacity (IT, total or ≈ planned)." className="scroll-mt-28" aside={<span className="figure text-[12px] text-ink-3">{fmtInt(facTotal)}</span>}>432 <PagedFacilities items={m.facilities} total={facTotal} page={facPage} perPage={facPer} hrefFor={(p) => fPageHref(base, sp, p)} columns={["operator", "status", "mw", "type", "confidence"]} emptyTitle="No facilities indexed in this market yet" />433 </Section>434435 {claims.length > 0 && (436 <Section id="claims" title="Claims about this market" description="Figures asserted about the market itself (metro-scoped) — shown as evidence, never summed into facility totals." className="scroll-mt-28">437 <ClaimsTable claims={claims} kind="all" />438 </Section>439 )}440 </div>441442 <SourcesFooter sources={r.sources} note={pipe === null && m.knownMw === null ? "No MW figure has been published by the sources indexed for this market; counts are exact, capacities are shown only when a source publishes them." : undefined} />443 </Page>444 );445}446